iT邦幫忙

2023 iThome 鐵人賽

DAY 8
1
Mobile Development

30 天輕鬆學會 Flutter 測試系列 第 8

Day 8 假的,都是假的,但不是業障重

  • 分享至 

  • xImage
  •  

我們介紹了許多單元測試技巧,可以幫助我們處理難測試的類別、做假資料或隔離外部依賴,這些手段可以處理絕大多數的狀況。如果我們設計物件的時候,都依照 CQS 的概念來把命令與查詢方法分開,當我們針對命令方法寫測試時,會發現常常需要使用 Mock。當我們針對查詢方法寫測試,則比較常使用 Stub,但是今天既不是要講 Stub,也不是 Mock,而是另一個測試替身 Fake

舉個例子

假設今天想在電商產品中新增一個我的最愛的功能,使用者可以把喜歡的商品加入我的最愛,然後可以在我的最愛的頁面,看到曾經加入的商品。根據需求,我們做了一個 MyFavorites 類別,身上有一個 add 方法,當使用者在 UI 介面上加入商品到我的最愛時,程式就會來呼叫 MyFavorites 的 add 方法,並把商品 id 放到 SharedPreference 中,以持久化 MyFavorites 的資料。

class MyFavorites {
  final SharedPreferences _preferences;

  MyFavorites(SharedPreferences preference) : _preferences = preference;

  Future<void> add(Product product) async {
    var favorites = getAll();
    favorites.add(product);
    await _preferences.setStringList("favorites",
        favorites.map((product) => product.id.toString()).toList());
  }

  List<Product> getAll() {
    return _preferences
            .getStringList("favorites")
            ?.map((id) => Product(int.parse(id)))
            .toList() ??
        [];
  }
}

當我們完成功能後,就可以應該順手加上一個測試,一方面驗證程式邏輯是否符合預期,一方面也避免後續重構壞掉,根據前面天介紹的技巧,由於這個方法沒有 return,我們使用 Mock 來測試它。

test("add favorite", () {
  var mockSharedPreferences = MockSharedPreferences();

  var myFavorites = MyFavorites(mockSharedPreferences);

  myFavorites.add(const Product(1));

  verify(mockSharedPreferences.setStringList("favorites", ["1"]));
});

在上面測試中,當我們呼叫了 add 方法之後,我們使用 Mock 來驗證 add 方法是否正確地與 MockSharedPreference 互動,也確認參數是否符合預期,綠燈通過,看起來也沒什麼問題,再來讓我們看看 getAll 的測試。

test("getAll", () {
  var mockSharedPreferences = MockSharedPreferences();

  when(mockSharedPreferences.getStringList("favorites")).thenReturn(["1"]);

  var myFavorites = MyFavorites(mockSharedPreferences);

  expect(myFavorites.getAll(), [const Product(1)]);
});

在 getAll 測試中,我們使用 Stub 來設定假資料 [”1”],並呼叫 getAll 取得回傳值確認結果,也是綠燈通過。[範例連結]

不知道觀眾朋友看完兩個測試之後,有沒有覺得不太對勁的地方?若我們以黑箱測試的角度來看,外面的人對這個物件預期是塞了一個 id = 1 的 Product 進去,要能從 getAll 取回包含 id = 1 的 Product 的 List,那我們是不是能用這種方法測試呢?

驗證狀態取代驗證行為

在前幾天的文章中,我們有提到相比於使用驗證互動,我們更傾向於驗證狀態,我們希望能夠呼叫 MyFavorites 的 add 方法後,再驗證 MyFavorites 的 getAll 方法回傳的 MyFavorites,就像下面例子那樣。

test() {
	...

	myFavorites.add(Product(1));

	expect(myFavorites.getAll(), [Product(1)]);
}

但是在 MyFavorites 的例子中,由於 MyFavorites 實際上是交給外部依賴儲存,所以在 Mock 測試中,我們呼叫 add 方法,卻無法用 getAll 方法來取回結果。如果想完成這個驗證狀態的測試,我們就必須請出其他測試替身來幫忙了。

測試替身之一:Fake

Fake 是一種假的實作,一種可以工作的簡易實作,用來替代真正的產品程式碼。

1.gif

出處:http://xunitpatterns.com/Fake%20Object.html

同樣以 MyFavorites 例子來說,我們可以做一個 FakeSharePreference 來替代 MockSharePreference。

class FakeSharedPreferences implements SharedPreferences {
  List<String> fake = [];

  @override
  Future<bool> setStringList(String key, List<String> value) async {
    fake = value;
    return true;
  }

  @override
  List<String>? getStringList(String key) {
    return fake;
  }

  @override
  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

我們在測試中建立一個 FakeSharedPreference 之後,修改一下測試,讓他使用這個假的 SharePreference,最後就能用 getAll 方法取回結果並驗證,綠燈通過。

test("add favorite", () {
  var fakeSharedPreferences = FakeSharedPreferences();

  var myFavorites = MyFavorites(fakeSharedPreferences);

  myFavorites.add(const Product(1));

  expect(myFavorites.getAll(), [const Product(1)]);
});

可以發現最後程式碼也變得更好懂一些,這個 FakeSharePreference 也能重複使用在其他與 SharePreference 相依的物件測試中。[範例連結]

使用 Fake 的優勢

與 Mock 相比,在使用 Fake 的測試中,我們能更完整測試一個類別的使用行為。在正式程式碼的行為中,我們如果用 MyFavorites 存了一筆資料,我們就理所當然的能從 MyFavorites 取回一筆資料,在 Fake 測試中也是如此。但是如果我們使用 Mock 來測試的話,在 MyFavorites 中寫了一筆資料,我們是無法從 MyFavorites 中讀回一筆資料的,因為 MockSharedPreference 的 setStringList 方法是假的。

使用 Fake 還有另外一個優點是,當我們調整 MyFavorites 的實作時,相關測試有機會不用跟著大調整。在 Mock 測試中,測試對於 MyFavorites 是如何實作有一定了解,因為測試知道 MyFavorites 怎麼使用 SharedPreference 的 setStringList。

讓我們修改一下程式碼,假設 Product 因需求調整多了 type 之後,需要在 SharedPreference 存 json 而非 id。

class MyFavorites {
  final SharedPreferences _preferences;

  MyFavorites(SharedPreferences preference) : _preferences = preference;

  Future<void> add(Product product) async {
    var favorites = getAll();
    favorites.add(product);
    await _preferences.setStringList("favorites",
        favorites.map((product) => jsonEncode(product.toJson())).toList());
  }

  List<Product> getAll() {
    return _preferences
        .getStringList("favorites")
        ?.map((json) => Product.fromJson(jsonDecode(json)))
        .toList() ??
        [];
  }
}

如果我們執行一下原本的 Mock 程式之後就會發現測試錯了,因為呼叫 SharedPreference 的 setStringList 時所傳入的參數格式完全不一樣了。

2.png

在 Mock 測試中,我們不只需要在 Product 物件上加上 type,還得修改 verify 的預期結果,遇上參數是 json 陣列又更難處理了。類似的事情也得在 getAll 測試上也要再做一次,必須要調整 MockSharedPreference 的 getStringList 方法的回傳值。

test("add favorite", () {
  var mockSharedPreferences = MockSharedPreferences();

  var myFavorites = MyFavorites(mockSharedPreferences);

  myFavorites.add(const Product(1, "book"));

  verify(mockSharedPreferences.setStringList("favorites", ['{"id":1,"type":"book"}']));
});

test("getAll", () {
  var mockSharedPreferences = MockSharedPreferences();

  when(mockSharedPreferences.getStringList("favorites")).thenReturn(['{"id":1,"type":"book"}']);

  var myFavorites = MyFavorites(mockSharedPreferences);

  expect(myFavorites.getAll(), [const Product(1, "book")]);
});

如果我們使用 Fake 來測試,當我們調整需求後,其實也就只是在 Product 上加入新的 type 參數而已,調整幅度縮小許多。

test("add favorite", () {
  var fakeSharedPreferences = FakeSharedPreferences();

  var myFavorites = MyFavorites(fakeSharedPreferences);

  myFavorites.add(const Product(1, "book"));

  expect(myFavorites.getAll(), [const Product(1, "book")]);
});

有興趣的觀眾朋友可以參考這邊 [Mock 測試修改後範例] [Fake 測試修改後範例]。

支援測試的套件

在實務上,我也們能將這個技巧用於資料庫上,比如我們能寫一個用 Memory 儲存資料的 FakeMemoryDB,在測試中讓 SUT 使用這個 FakeMemoryDB,像是真的資料庫一樣。我們先前提過,有些套件對測試有比較好的支援度,讓開發者可以省一些麻煩,例如:drift 有提供 Memory 版本的資料庫,需要在測試中 Fake 資料庫時就比較方便。

test("add favorite", () async {
  MyDatabase database = MyDatabase(NativeDatabase.memory());

  var myFavorites = MyFavorites(database);

  await myFavorites.add(const Product(id: 1, type: "book"));

  expect(await myFavorites.getAll(), [const Product(id: 1, type: "book")]);

  await database.close();
});

在上面的測試中,我們用 Memory 來替代真的資料庫放在測試中使用,更詳細的例子可以看[這邊]。

小結

相比於 Stub 與 Mock,Fake 是比較少聽過的測試替身,但是其實 Fake 相當的好用,尤其是當類別行為會跟於外部依賴的狀態有關時,使用 Fake 能更完整的測試整個類別的行為,而不用把一個完整行為拆分成很多個測試,讓我們能直接測試一個類別的完整行為,除了測試數量可能會少一點之外,測試也不容易因為程式碼一改就壞。


上一篇
Day 7 程式開發不只有正常路徑
下一篇
Day 9 如何在 Dart 中輕鬆測試時間
系列文
30 天輕鬆學會 Flutter 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言